Skip to content

fix(pg): the readiness mount check sees a router Sentry has wrapped - #1958

Merged
lilyshen0722 merged 1 commit into
mainfrom
fix/pg-mount-probe-under-sentry
Sep 27, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
fix/pg-mount-probe-under-sentry

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

P0 for deploys. Deploy 4e60f24 (#1901) hung at the backend rollout, and every backend deploy will hang the same way until this lands.

The new pod connected to Postgres and mounted /api/pg/messages, which answered 401. But /api/health/ready said "PostgreSQL routes are not mounted on this pod", so the pod never became Ready. Production stayed safe: the old pod kept serving, and the frontend rolled out.

Cause. routerIsMounted compared layer.handle === router, and @sentry/node 10.65, initialised in production (SENTRY_DSN is set), wraps every Express layer handle.

Reproduced locally with the backend's own modules:

identity match router's stack still the same array
Sentry initialised false true
Sentry not loaded true true

Fix. The check also accepts a layer whose handle exposes the router's own stack array. That is still an identity question about this router, not a path match.

Tests. Two build a real Express app and wrap the layer the way Sentry does:

  • the mounted router is found. This one is red on main's pgBootService, and I checked by restoring main's file;
  • a different wrapped router is not found.

pgBootService.test.js passes; eslint ran through lint-staged. server.test.js fails locally with a Node 26 jsonwebtoken load error, identically on main, so CI is the reference for it.

Gate: sprint-review, who gated #1901.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RUTWqLu6zhFPraoPHZcjQP

Deploy 4e60f24 (#1901) hung at the backend rollout. The new pod connected to
Postgres and mounted /api/pg/messages (it answered 401), but /api/health/ready
said "PostgreSQL routes are not mounted on this pod" and the pod never became
Ready. routerIsMounted compared layer.handle === router, and @sentry/node
10.65, initialised in production, wraps every Express layer handle. Reproduced
locally with the backend's own modules: with Sentry initialised the identity
check is false and the router's stack is still the same array; without Sentry
both are true.

The check now also accepts a layer whose handle exposes the router's own stack
array. That is still an identity question about this router, not a path match.
Two tests build a real Express app and wrap the layer the way Sentry does: the
mounted router is found, and a different wrapped router is not. The first is
red on main's pgBootService.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUTWqLu6zhFPraoPHZcjQP

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CODE GATE: PASS @ 136530ee — sprint-review. Behind 0, author Lily, 2 files / +47.

I gated #1901, whose identity comparison is the defect, so I checked the premise against the real library rather than this PR's stand-in for it.

Real @sentry/node 10.65.0, plain node, Sentry.init() before require('express'): the mounted layer's handle is a function named layerHandlePatched; handle === router is false; handle.stack === router.stack is true. The reproduction in the description holds against the actual library, not just against wrapLikeSentry.

The decisive run — real Sentry, plain node (the production module-loading path), the real routerIsMounted required from each tree:

tree mounted router unmounted wrong router
main e6a8252 false ← the incident false false
head 136530e true false false

The incident reproduces against main's code under real instrumentation, this fix resolves it, and it introduces no false positive. Array identity is the correct key: only a wrapper of that router can hold its stack object, so the clause cannot alias a different router.

Mutation, anchor count 1 (no line-scoping needed):

  • BASE — 15/15.
  • Delete the new clause — finds the mounted router when instrumentation has wrapped its layer handle reddens. 1 failed / 15.
  • Invert to !== — does not mistake a different wrapped router for this one reddens. 1 failed / 15.
  • RESTORED — 15/15, git diff --quiet clean.

The inversion mutant is the useful one: it shows the second test is not redundant with the first. It pins false-positive safety rather than mere presence, which is exactly the property a .stack comparison needs.

PR tests run against main's pgBootService: exactly one red, the found-case — the claim in the description is confirmed. Collateral: 45/45 across pgBootService, routes/health, config/db-pg. CI has nothing red; Test & Coverage is still running, which is the whole of the BLOCKED mergeStateStatus.


One structural limit worth recording. Not a blocker, and not a criticism of the approach.

The unit tier cannot host real Sentry. I ran this suite's scenario with a genuine Sentry.init() under jest and got handle === router true — no wrapping at all. OTel's require-in-the-middle hook never sees express through jest's module registry. So wrapLikeSentry isn't a shortcut here; it is the only thing available at this tier, and hand-rolling it was right.

The consequence is what matters: these tests pin the fix, not the premise. They assert that routerIsMounted handles a wrapper shaped the way we believe Sentry's is. Nothing in CI asserts that Sentry still produces that shape. If a Sentry upgrade stops exposing .stack on the wrapper, all 15 stay green and /ready starts lying again in exactly the way it just did — with the same signature, a pod serving 401 on /api/pg/messages while reporting "not mounted".

That gap is cheap to close with the ~20-line plain-node probe I used for the table above: real Sentry.init(), real express, assert handle !== router && handle.stack === router.stack. It belongs in a follow-up as a script, not in this P0.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correction to my gate above — the "structural limit" section overstated the consequence. The verdict (PASS @ 136530ee) is unchanged.

I wrote that if a Sentry upgrade stops exposing .stack on the wrapper, "all 15 stay green and /ready starts lying again in exactly the way it just did." @sprint-impl pointed out there is a real-Sentry tier after all — the deploy — and they are right. I checked it rather than taking it:

  • .github/workflows/deploy-dev.yml:206 — "Verify rollout of the deployed workloads" derives the deploy list from the cluster, runs kubectl rollout status … --timeout=8m per workload, accumulates into FAILED, and exit 1s if non-empty. A stuck rollout does fail the job.
  • :238 — "Verify the new backend serves chat" execs the newest running backend pod and requires /api/pg/status to contain "available":true and /api/pg/messages/000… to answer 401, 6 attempts × 10s, else exit 1 with a named error.

So on a recurrence: routerIsMounted returns false → /ready 503 → the pod never becomes Ready → the rollout gate goes red, and the old pod keeps serving throughout. That is a failed deploy, not a silent production break. My sentence was wrong on that point and I withdraw it.

The residual gap is narrower than I said, and differently shaped than "timing."

The chat-check step at :238 carries no if: condition — the if: always() at :267 belongs to the later "unhealthy workloads" notice step — so when the rollout gate at :206 fails first, the chat check is skipped.

In a recurrence the two gates would disagree in the informative direction: the rollout gate red because readiness is lying, while the pod genuinely serves 401 because the routes really are mounted. But the step whose error text spells out 401 means mounted, 404 unmounted is precisely the one that does not run. What the operator actually receives is backend did not become ready within 8m, which does not distinguish "readiness is lying about a working pod" from "the pod is broken" — and that distinction is the diagnosis this PR exists to have made faster.

So the residual is timing plus diagnostic specificity, and both are worth closing:

  1. A one-line workflow change — if: always() (or !cancelled()) on the :238 step — so a stuck rollout arrives with "…but the pod answers 401" attached, naming the cause immediately.
  2. The pre-merge tier, filed as TASK-172. @sprint-impl's design point is the part I had wrong: it cannot be a bare script, because a script with no caller rots and the tier that would call it is jest, which is what prevents OTel's require-hook from patching express in the first place. The shape that works is a jest test that spawns node as a child, runs real Sentry.init() + real express + the real routerIsMounted in the child, and asserts its exit code. My probe for the table above ran in plain node, so that is a faithful wrapper of a measurement that already works.

Nothing here blocks this PR; it is still PASS. Recording it because the claim was published in this thread, and a retraction that stays in chat does not reach anyone reading the review.

Merged via the queue into main with commit ff8de0f Sep 27, 2026
17 checks passed
samxu01 pushed a commit that referenced this pull request Sep 27, 2026
The premise guard's suite is red on main by construction — main's
pgBootService has no stack clause — so it was held until #1958 merged. Now
the tree under test carries the fix the guard pins.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant